| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625 |
- import { useEffect, useMemo, useState } from 'react';
- import {
- Alert,
- Image,
- KeyboardAvoidingView,
- Modal,
- Platform,
- Pressable,
- ScrollView,
- StyleSheet,
- TextInput,
- View,
- } from 'react-native';
- import * as ImagePicker from 'expo-image-picker';
- import { ResizeMode, Video } from 'expo-av';
- import { useLocalSearchParams, useRouter } from 'expo-router';
- import { ThemedButton } from '@/components/themed-button';
- import { IconButton } from '@/components/icon-button';
- import { ThemedText } from '@/components/themed-text';
- import { ThemedView } from '@/components/themed-view';
- import { ZoomImageModal } from '@/components/zoom-image-modal';
- import { Colors } from '@/constants/theme';
- import { useColorScheme } from '@/hooks/use-color-scheme';
- import { useTranslation } from '@/localization/i18n';
- import { dbPromise, initCoreTables } from '@/services/db';
- type FieldRow = {
- id: number;
- name: string | null;
- };
- type CropRow = {
- id: number;
- crop_name: string | null;
- };
- type ObservationRow = {
- id: number;
- field_id: number | null;
- crop_id: number | null;
- obs_type: string | null;
- note: string | null;
- severity: number | null;
- observed_at: string | null;
- };
- type ImageRow = {
- uri: string | null;
- };
- export default function ObservationDetailScreen() {
- const { t } = useTranslation();
- const router = useRouter();
- const { id } = useLocalSearchParams<{ id?: string | string[] }>();
- const observationId = Number(Array.isArray(id) ? id[0] : id);
- const theme = useColorScheme() ?? 'light';
- const palette = Colors[theme];
- const [loading, setLoading] = useState(true);
- const [status, setStatus] = useState('');
- const [fields, setFields] = useState<FieldRow[]>([]);
- const [crops, setCrops] = useState<CropRow[]>([]);
- const [fieldModalOpen, setFieldModalOpen] = useState(false);
- const [cropModalOpen, setCropModalOpen] = useState(false);
- const [selectedFieldId, setSelectedFieldId] = useState<number | null>(null);
- const [selectedCropId, setSelectedCropId] = useState<number | null>(null);
- const [type, setType] = useState('');
- const [severity, setSeverity] = useState('');
- const [note, setNote] = useState('');
- const [mediaUris, setMediaUris] = useState<string[]>([]);
- const [activeUri, setActiveUri] = useState<string | null>(null);
- const [errors, setErrors] = useState<{ field?: string; severity?: string }>({});
- const [zoomUri, setZoomUri] = useState<string | null>(null);
- const [saving, setSaving] = useState(false);
- const [showSaved, setShowSaved] = useState(false);
- useEffect(() => {
- let isActive = true;
- async function loadObservation() {
- try {
- await initCoreTables();
- const db = await dbPromise;
- const fieldRows = await db.getAllAsync<FieldRow>('SELECT id, name FROM fields ORDER BY name ASC;');
- const cropRows = await db.getAllAsync<CropRow>('SELECT id, crop_name FROM crops ORDER BY crop_name ASC;');
- const obsRows = await db.getAllAsync<ObservationRow>(
- 'SELECT id, field_id, crop_id, obs_type, note, severity, observed_at FROM observations WHERE id = ? LIMIT 1;',
- observationId
- );
- if (!isActive) return;
- setFields(fieldRows);
- setCrops(cropRows);
- const obs = obsRows[0];
- if (!obs) {
- setStatus(t('observations.empty'));
- setLoading(false);
- return;
- }
- setSelectedFieldId(obs.field_id ?? null);
- setSelectedCropId(obs.crop_id ?? null);
- setType(obs.obs_type ?? '');
- setSeverity(obs.severity !== null ? String(obs.severity) : '');
- setNote(obs.note ?? '');
- const imageRows = await db.getAllAsync<ImageRow>(
- 'SELECT uri FROM images WHERE observation_id = ? ORDER BY created_at ASC;',
- observationId
- );
- const media = uniqueMediaUris(imageRows.map((row) => row.uri).filter(Boolean) as string[]);
- setMediaUris(media);
- setActiveUri(media[0] ?? null);
- } catch (error) {
- if (isActive) setStatus(`Error: ${String(error)}`);
- } finally {
- if (isActive) setLoading(false);
- }
- }
- loadObservation();
- return () => {
- isActive = false;
- };
- }, [observationId, t]);
- const selectedField = useMemo(
- () => fields.find((item) => item.id === selectedFieldId),
- [fields, selectedFieldId]
- );
- const selectedCrop = useMemo(
- () => crops.find((item) => item.id === selectedCropId),
- [crops, selectedCropId]
- );
- const inputStyle = [
- styles.input,
- {
- borderColor: palette.border,
- backgroundColor: palette.input,
- color: palette.text,
- },
- ];
- const typePresets = ['scouting', 'pest', 'disease', 'weeds', 'nutrients', 'irrigation'];
- async function handleUpdate() {
- const parsedSeverity = severity.trim() ? Number(severity) : null;
- const nextErrors: { field?: string; severity?: string } = {};
- if (!selectedFieldId) {
- nextErrors.field = t('observations.fieldRequired');
- }
- if (severity.trim() && !Number.isFinite(parsedSeverity)) {
- nextErrors.severity = t('observations.severityInvalid');
- }
- setErrors(nextErrors);
- if (Object.keys(nextErrors).length > 0) return;
- try {
- setSaving(true);
- const db = await dbPromise;
- await db.runAsync(
- 'UPDATE observations SET field_id = ?, crop_id = ?, obs_type = ?, note = ?, severity = ? WHERE id = ?;',
- selectedFieldId,
- selectedCropId,
- type.trim() || null,
- note.trim() || null,
- parsedSeverity,
- observationId
- );
- await db.runAsync('DELETE FROM images WHERE observation_id = ?;', observationId);
- const now = new Date().toISOString();
- for (const uri of uniqueMediaUris(mediaUris)) {
- await db.runAsync(
- 'INSERT INTO images (observation_id, uri, created_at) VALUES (?, ?, ?);',
- observationId,
- uri,
- now
- );
- }
- setStatus(t('observations.saved'));
- setShowSaved(true);
- setTimeout(() => {
- setShowSaved(false);
- setStatus('');
- }, 1800);
- } catch (error) {
- setStatus(`Error: ${String(error)}`);
- } finally {
- setSaving(false);
- }
- }
- function confirmDelete() {
- Alert.alert(
- t('observations.deleteTitle'),
- t('observations.deleteMessage'),
- [
- { text: t('observations.cancel'), style: 'cancel' },
- {
- text: t('observations.delete'),
- style: 'destructive',
- onPress: async () => {
- const db = await dbPromise;
- await db.runAsync('DELETE FROM images WHERE observation_id = ?;', observationId);
- await db.runAsync('DELETE FROM observations WHERE id = ?;', observationId);
- router.back();
- },
- },
- ]
- );
- }
- if (loading) {
- return (
- <ThemedView style={[styles.container, { backgroundColor: palette.background }]}>
- <ThemedText>{t('observations.loading')}</ThemedText>
- </ThemedView>
- );
- }
- return (
- <ThemedView style={[styles.container, { backgroundColor: palette.background }]}>
- <KeyboardAvoidingView
- behavior={Platform.OS === 'ios' ? 'padding' : undefined}
- style={styles.keyboardAvoid}>
- <ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
- <ThemedText type="title">{t('observations.edit')}</ThemedText>
- {status && !showSaved ? <ThemedText>{status}</ThemedText> : null}
- <ThemedText>{t('observations.field')}</ThemedText>
- <ThemedButton
- title={selectedField?.name || t('observations.selectField')}
- onPress={() => setFieldModalOpen(true)}
- variant="secondary"
- />
- {errors.field ? <ThemedText style={styles.errorText}>{errors.field}</ThemedText> : null}
- <ThemedText>{t('observations.crop')}</ThemedText>
- <ThemedButton
- title={selectedCrop?.crop_name || t('observations.selectCrop')}
- onPress={() => setCropModalOpen(true)}
- variant="secondary"
- />
- <ThemedText>{t('observations.type')}</ThemedText>
- <View style={styles.chipRow}>
- {typePresets.map((preset) => {
- const label = t(`observations.type.${preset}`);
- const isActive = type === label || type === preset;
- return (
- <Pressable
- key={preset}
- style={[styles.chip, isActive ? styles.chipActive : null]}
- onPress={() => setType(label)}>
- <ThemedText style={styles.chipText}>{label}</ThemedText>
- </Pressable>
- );
- })}
- </View>
- <TextInput
- value={type}
- onChangeText={setType}
- placeholder={t('observations.typePlaceholder')}
- placeholderTextColor={palette.placeholder}
- style={inputStyle}
- />
- <ThemedText>{t('observations.severity')}</ThemedText>
- <TextInput
- value={severity}
- onChangeText={(value) => {
- setSeverity(value);
- if (errors.severity) setErrors((prev) => ({ ...prev, severity: undefined }));
- }}
- placeholder={t('observations.severityPlaceholder')}
- placeholderTextColor={palette.placeholder}
- style={inputStyle}
- keyboardType="decimal-pad"
- />
- {errors.severity ? <ThemedText style={styles.errorText}>{errors.severity}</ThemedText> : null}
- <ThemedText>{t('observations.note')}</ThemedText>
- <TextInput
- value={note}
- onChangeText={setNote}
- placeholder={t('observations.notePlaceholder')}
- placeholderTextColor={palette.placeholder}
- style={inputStyle}
- multiline
- />
- <ThemedText>{t('observations.addMedia')}</ThemedText>
- {normalizeMediaUri(activeUri) ? (
- isVideoUri(normalizeMediaUri(activeUri) as string) ? (
- <Video
- source={{ uri: normalizeMediaUri(activeUri) as string }}
- style={styles.mediaPreview}
- useNativeControls
- resizeMode={ResizeMode.CONTAIN}
- />
- ) : (
- <Pressable onPress={() => setZoomUri(normalizeMediaUri(activeUri) as string)}>
- <Image source={{ uri: normalizeMediaUri(activeUri) as string }} style={styles.mediaPreview} resizeMode="contain" />
- </Pressable>
- )
- ) : (
- <ThemedText style={styles.photoPlaceholder}>{t('observations.noPhoto')}</ThemedText>
- )}
- {mediaUris.length > 0 ? (
- <ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.mediaStrip}>
- {mediaUris.map((uri) => (
- <Pressable key={uri} style={styles.mediaChip} onPress={() => setActiveUri(uri)}>
- {isVideoUri(uri) ? (
- <View style={styles.videoThumb}>
- <ThemedText style={styles.videoThumbText}>▶</ThemedText>
- </View>
- ) : (
- <Image source={{ uri }} style={styles.mediaThumb} resizeMode="cover" />
- )}
- <Pressable
- style={styles.mediaRemove}
- onPress={(event) => {
- event.stopPropagation();
- setMediaUris((prev) => {
- const next = prev.filter((item) => item !== uri);
- setActiveUri((current) => (current === uri ? next[0] ?? null : current));
- return next;
- });
- }}>
- <ThemedText style={styles.mediaRemoveText}>×</ThemedText>
- </Pressable>
- </Pressable>
- ))}
- </ScrollView>
- ) : null}
- <View style={styles.photoRow}>
- <ThemedButton
- title={t('observations.pickFromGallery')}
- onPress={() =>
- handlePickMedia((uris) => {
- if (uris.length === 0) return;
- setMediaUris((prev) => uniqueMediaUris([...prev, ...uris]));
- setActiveUri((prev) => prev ?? uris[0]);
- })
- }
- variant="secondary"
- />
- <ThemedButton
- title={t('observations.takeMedia')}
- onPress={() =>
- handleTakeMedia((uri) => {
- if (!uri) return;
- setMediaUris((prev) => uniqueMediaUris([...prev, uri]));
- setActiveUri((prev) => prev ?? uri);
- })
- }
- variant="secondary"
- />
- </View>
- <View style={styles.actions}>
- <IconButton
- name="trash"
- onPress={confirmDelete}
- accessibilityLabel={t('observations.delete')}
- variant="danger"
- />
- <View style={styles.updateGroup}>
- {showSaved ? <ThemedText style={[styles.inlineToastText, { color: palette.success }]}>{t('observations.saved')}</ThemedText> : null}
- <ThemedButton
- title={saving ? t('observations.saving') : t('observations.update')}
- onPress={handleUpdate}
- disabled={saving}
- />
- </View>
- </View>
- </ScrollView>
- </KeyboardAvoidingView>
- <Modal transparent visible={fieldModalOpen} animationType="fade">
- <Pressable style={styles.modalBackdrop} onPress={() => setFieldModalOpen(false)}>
- <View style={styles.modalCard}>
- <ThemedText type="subtitle">{t('observations.selectField')}</ThemedText>
- <ScrollView style={styles.modalList}>
- {fields.map((item) => (
- <Pressable
- key={item.id}
- style={styles.modalItem}
- onPress={() => {
- setSelectedFieldId(item.id);
- setFieldModalOpen(false);
- }}>
- <ThemedText>{item.name || t('observations.untitled')}</ThemedText>
- </Pressable>
- ))}
- </ScrollView>
- </View>
- </Pressable>
- </Modal>
- <Modal transparent visible={cropModalOpen} animationType="fade">
- <Pressable style={styles.modalBackdrop} onPress={() => setCropModalOpen(false)}>
- <View style={styles.modalCard}>
- <ThemedText type="subtitle">{t('observations.selectCrop')}</ThemedText>
- <ScrollView style={styles.modalList}>
- {crops.map((item) => (
- <Pressable
- key={item.id}
- style={styles.modalItem}
- onPress={() => {
- setSelectedCropId(item.id);
- setCropModalOpen(false);
- }}>
- <ThemedText>{item.crop_name || t('observations.untitled')}</ThemedText>
- </Pressable>
- ))}
- </ScrollView>
- </View>
- </Pressable>
- </Modal>
- <ZoomImageModal uri={zoomUri} visible={Boolean(zoomUri)} onClose={() => setZoomUri(null)} />
- </ThemedView>
- );
- }
- async function handlePickMedia(onAdd: (uris: string[]) => void) {
- const result = await ImagePicker.launchImageLibraryAsync({
- mediaTypes: getMediaTypes(),
- quality: 1,
- allowsMultipleSelection: true,
- selectionLimit: 0,
- });
- if (result.canceled) return;
- const uris = (result.assets ?? []).map((asset) => asset.uri).filter(Boolean) as string[];
- if (uris.length === 0) return;
- onAdd(uris);
- }
- async function handleTakeMedia(onAdd: (uri: string | null) => void) {
- const permission = await ImagePicker.requestCameraPermissionsAsync();
- if (!permission.granted) {
- return;
- }
- const result = await ImagePicker.launchCameraAsync({
- mediaTypes: getMediaTypes(),
- quality: 1,
- });
- if (result.canceled) return;
- const asset = result.assets[0];
- onAdd(asset.uri);
- }
- function getMediaTypes() {
- const mediaType = (ImagePicker as {
- MediaType?: { Image?: unknown; Images?: unknown; Video?: unknown; Videos?: unknown };
- }).MediaType;
- const imageType = mediaType?.Image ?? mediaType?.Images;
- const videoType = mediaType?.Video ?? mediaType?.Videos;
- if (imageType && videoType) {
- return [imageType, videoType];
- }
- return imageType ?? videoType ?? ['images', 'videos'];
- }
- function isVideoUri(uri: string) {
- return /\.(mp4|mov|m4v|webm|avi|mkv)(\?.*)?$/i.test(uri);
- }
- function normalizeMediaUri(uri?: string | null) {
- if (typeof uri !== 'string') return null;
- const trimmed = uri.trim();
- return trimmed ? trimmed : null;
- }
- function uniqueMediaUris(uris: string[]) {
- const seen = new Set<string>();
- const result: string[] = [];
- for (const uri of uris) {
- if (!uri || seen.has(uri)) continue;
- seen.add(uri);
- result.push(uri);
- }
- return result;
- }
- const styles = StyleSheet.create({
- container: {
- flex: 1,
- },
- keyboardAvoid: {
- flex: 1,
- },
- content: {
- padding: 16,
- gap: 10,
- paddingBottom: 40,
- },
- input: {
- borderRadius: 10,
- borderWidth: 1,
- paddingHorizontal: 12,
- paddingVertical: 10,
- fontSize: 15,
- },
- errorText: {
- color: '#C0392B',
- fontSize: 12,
- },
- mediaPreview: {
- width: '100%',
- height: 220,
- borderRadius: 12,
- backgroundColor: '#1C1C1C',
- },
- photoRow: {
- flexDirection: 'row',
- gap: 8,
- },
- actions: {
- marginTop: 12,
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- gap: 10,
- },
- photoPlaceholder: {
- opacity: 0.6,
- },
- mediaStrip: {
- marginTop: 6,
- },
- mediaChip: {
- width: 72,
- height: 72,
- borderRadius: 10,
- marginRight: 8,
- overflow: 'hidden',
- backgroundColor: '#E6E1D4',
- alignItems: 'center',
- justifyContent: 'center',
- },
- mediaThumb: {
- width: '100%',
- height: '100%',
- },
- videoThumb: {
- width: '100%',
- height: '100%',
- backgroundColor: '#1C1C1C',
- alignItems: 'center',
- justifyContent: 'center',
- },
- videoThumbText: {
- color: '#FFFFFF',
- fontSize: 18,
- fontWeight: '700',
- },
- mediaRemove: {
- position: 'absolute',
- top: 4,
- right: 4,
- width: 18,
- height: 18,
- borderRadius: 9,
- backgroundColor: 'rgba(0,0,0,0.6)',
- alignItems: 'center',
- justifyContent: 'center',
- },
- mediaRemoveText: {
- color: '#FFFFFF',
- fontSize: 12,
- lineHeight: 14,
- fontWeight: '700',
- },
- updateGroup: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 8,
- },
- inlineToastText: {
- fontWeight: '700',
- fontSize: 12,
- },
- chipRow: {
- flexDirection: 'row',
- flexWrap: 'wrap',
- gap: 8,
- marginBottom: 8,
- },
- chip: {
- paddingHorizontal: 12,
- paddingVertical: 6,
- borderRadius: 999,
- borderWidth: 1,
- borderColor: '#D9D1C2',
- backgroundColor: '#F8F6F0',
- },
- chipActive: {
- backgroundColor: '#DDE8DA',
- borderColor: '#88A68F',
- },
- chipText: {
- fontSize: 13,
- },
- modalBackdrop: {
- flex: 1,
- backgroundColor: 'rgba(0,0,0,0.4)',
- justifyContent: 'center',
- padding: 24,
- },
- modalCard: {
- borderRadius: 14,
- backgroundColor: '#FFFFFF',
- padding: 16,
- gap: 10,
- maxHeight: '80%',
- },
- modalList: {
- maxHeight: 300,
- },
- modalItem: {
- paddingVertical: 10,
- },
- });
|